import { BinaryToTextEncoding, createHash } from 'crypto'
import { castArray, isArray, isObject } from 'lodash-es'
import _mergeSourceMap from 'merge-source-map'
import { lookup as _lookup } from 'mime-types'
import { tmpdir } from 'os'
import path, { basename as _basename, extname as _extname, posix, posix as posixPath } from 'path'
import type {
  AcornNode,
  Awaitable,
  CompluginContext,
  CompluginTransformContext,
  EmitAssetArgs,
  SourceMap,
  SourceMapInput
} from './types'
import ansiColors from 'ansi-colors'
import globToRegExp from 'glob-to-regexp'
import { once } from 'lodash-es'
import { builtinModules, createRequire } from 'module'
import { fileURLToPath } from 'url'
import type { CompluginOptions } from './types'

export const CWD = process.cwd()

const returnTrue = () => true

export const normalizeRawOptions = (rawOptions: CompluginOptions) => {
  const normalizedOptions: CompluginOptions & { transformInclude: NonNullable<CompluginOptions['transformInclude']> } =
    Object.fromEntries(
      Object.entries(rawOptions).map(([key, value]) => [key, typeof value === 'function' ? value : undefined])
    ) as any

  if (!normalizedOptions.transformInclude) {
    normalizedOptions.transformInclude = returnTrue
  }

  return normalizedOptions
}

export const joinIterables = function* <T>(...iterables: Iterable<T>[]) {
  for (const iterable of iterables) {
    for (const item of iterable) {
      yield item
    }
  }
}

export const sortPlugins = (plugins: any[]) => {
  return plugins.sort((plugin1, plugin2) => {
    let order1 = 0
    let order2 = 0
    switch ((plugin1 as any)?.enforce) {
      case 'pre':
        order1 = -1
        break
      case 'post':
        order1 = 1
        break
    }

    switch ((plugin2 as any)?.enforce) {
      case 'pre':
        order2 = -1
        break
      case 'post':
        order2 = 1
        break
    }

    return order1 - order2
  })
}

export const _require = createRequire(__filename)
export const _safeRequireResolve = (id: string, options?: { paths: string[] }) => {
  try {
    return require.resolve(id, options)
  } catch (error) {}
}

const dependencies = Object.create(null)

export const loadDependency = (id: string, throwError = false) => {
  if (id in dependencies) return dependencies[id]

  try {
    const resolved = _require.resolve(id, { paths: [process.cwd()] })
    const dep = _require(resolved)

    dependencies[id] = dep

    return dep
  } catch (e) {
    if ((e as any)?.code === 'MODULE_NOT_FOUND' && /^[\w@]/.test(id)) {
      if (throwError) {
        throw new Error(`❌ Dependency "${id}" not found. Did you install it?`)
      }
    } else {
      const stack = (e as any)?.stack
      const message = (e as any)?.message
      const error = new Error(
        `❌ Dependency "${id}" failed to load` + (typeof message === 'string' ? ':\n' + message : '.')
      )
      if (stack && typeof stack === 'string') {
        error.stack = stack + '\n' + error.stack
      }
      if (throwError) {
        throw error
      }
    }
  }
}

export const esm_default = (mod: any) => {
  return mod && typeof mod === 'object' && 'default' in mod ? mod.default : mod
}

export const acornParse = once((): typeof import('acorn')['parse'] => {
  return loadDependency('acorn', true).parse
})

export const iterableMap = <T, U>(source: Iterable<T>, callbackFn: (value: T, index: number) => U) => {
  const newArray: U[] = []

  let i = 0
  for (const value of source) {
    newArray.push(callbackFn(value, i++))
  }

  return newArray
}

export type AsyncValue<T> = Promise<T> & { resolve(value: T | PromiseLike<T>): void; value?: T }

export const asyncValue = <T>(): AsyncValue<T> => {
  let resolve: any
  let _setTimeout: any

  const promise = new Promise<T>((res, rej) => {
    let timeId: any
    let isResolve = false
    resolve = (value: any) => {
      isResolve = true
      // @ts-ignore
      res((promise.value = value))
      clearTimeout(timeId)
      timeId = undefined
    }
    _setTimeout = (time?: number) => {
      if (isResolve) return
      timeId = setTimeout(() => {
        timeId = undefined
        if (isResolve) return

        isResolve = true
        rej(new Error('timeout'))
      }, time)
    }
  })

  promise.catch()

  const oldPromiseThen = promise.then

  // @ts-ignore
  promise.resolve = resolve
  promise.then = function (this: any, onfulfilled, onrejected) {
    _setTimeout(1000)
    promise.then = oldPromiseThen
    return oldPromiseThen.call(this, onfulfilled, onrejected)
  } as typeof oldPromiseThen

  return promise as any
}

export const slash = (p: string) => {
  return p.split('\\').join('/')
}

const pathSep = path.sep
const posixSep = posix.sep

export const normalizePath =
  pathSep === posixSep
    ? path.normalize
    : (source: string) => {
        return path.normalize(source).split(pathSep).join(posixSep)
      }

export const doHash = (
  source: string | Uint8Array | Array<string | Uint8Array>,
  encoding: BinaryToTextEncoding = 'hex',
  length: number = 8
) => {
  const hash = createHash('md5')
  if (isArray(source)) {
    for (const s of source) {
      hash.update(s)
    }
  } else {
    hash.update(source)
  }
  const hashStr = hash.digest(encoding)
  return length ? hashStr.slice(0, length).padStart(length, '_') : hashStr
}

export const escapeRegExp = (str: string) => str.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d')

export const toArray = <T = any>(data: any): T[] => {
  if (Array.isArray(data)) return data
  if (data == null) return []
  if (typeof data !== 'object') return [data]
  if (Symbol.iterator in data || 'length' in data) return Array.from(data as any)
  return [data]
}

export const toBuffer = (value: Buffer | Uint8Array | Parameters<typeof Buffer['from']>[0]) => {
  if (Buffer.isBuffer(value)) return value
  if (value instanceof Uint8Array) return Buffer.from(value.buffer)
  return Buffer.from(value)
}

export function lookupName(name?: string, defaultContentType = 'text/plain') {
  if (!name) return defaultContentType
  if (name.endsWith('.ico')) return 'image/x-icon'
  if (name.endsWith('.wasm')) return 'application/wasm'

  return _lookup(name) || defaultContentType
}

export const toDataUri = (source: Uint8Array | string, contentType: string = '') => {
  return `data:${contentType};base64,${toBuffer(source).toString('base64')}`
}

export const hex = (source: Uint8Array | string) => {
  return toBuffer(source).toString('hex')
}

export const unHex = (hex: string) => {
  return Buffer.from(hex, 'hex').toString()
}

export const base64url = (source: Uint8Array | string) => {
  return toBuffer(source).toString('base64url')
}

export const unBase64url = (base64url: string) => {
  return Buffer.from(base64url, 'base64url').toString()
}

export const addExtension = (filename: string, ext = '.js') => {
  let result = `${filename}`
  if (!getExtname(filename)) result += ext
  return result
}

export const replaceExtension = (filename: string, ext = '.js') => {
  const obj = path.parse(filename)
  return path.join(obj.dir, obj.name + ext)
}

export const normalizeModuleId = (id: string) => {
  if (id.includes(':')) return id
  id = normalizePath(id)
  if (id.startsWith('/')) return id
  if (!id.startsWith('./') && !id.startsWith('../')) {
    id = './' + id
  }

  return id
}

export const stringifyModuleId = (id: string) => {
  return JSON.stringify(normalizeModuleId(id))
}

export const base64urlTestRegExp = /^[\w-]+$/

export const isPromiseLike = <T>(value: any): value is PromiseLike<T> => {
  return value && typeof value === 'object' && typeof value['then'] === 'function'
}

export const getExtname = (source: string) => {
  const ext = _extname(source)
  if (ext) return ext
  const basename = _basename(source)
  if (basename.startsWith('.')) return basename
  return ''
}

const _makeLegalPath = (value?: string) => {
  if (!value) return ''

  let extName = getExtname(value)
  value = value.slice(0, value.length - extName.length)

  let [base, ...suffixArr] = value.split(/[#\?]/)
  if (base !== value) {
    const _extname = getExtname(base)
    if (_extname) {
      base = base.slice(0, base.length - _extname.length)
      extName = _extname
    }
  }

  suffixArr.forEach((suffix, i, arr) => (arr[i] = suffix.replace(/[_\\\/:]+/g, '_')))

  return ([base, ...suffixArr].join('_') + extName)
    .replace(/([\.\s]($|[\\\/]))/g, '')
    .replace(/([\\\/][\.\s])/g, '_')
    .replace(/[\p{C}\p{M}!@\$%\^&\*\(\)=\{\}\|;'"<,>]/gu, '_')
    .trim()
}

export const makeRelativeLegalPosixPath = (value: string) => {
  return posixPath.join('./', _makeLegalPath(normalizePath(value).split('..').pop()) || 'unknown', '.')
}

export const makeLegalPath = (value: string) => {
  let result = path.normalize(_makeLegalPath(_makeLegalPath(value)) || 'unknown')
  if (result.endsWith(path.sep)) {
    result = result.slice(0, result.length - path.sep.length)
  }

  return result
}

export const isEmitAssetArgs = (value: any): value is EmitAssetArgs => {
  if (value && typeof value === 'object' && 'source' in value) {
    const source = value.source
    return typeof source === 'string' || source instanceof Uint8Array || isObject(source)
  }

  return false
}

export const getInsertIndex = (code: string) => {
  return code.match(/^(?:\s*#[^\n]*)?[\s;]*(\/\*\*?.*?\*\/|(?<k1>['"])[^\n]*?(?<!\\)\k<k1>)[\s;]*/s)?.[0].length ?? 0
  // ([\s\n;]*(\/\*\*?.*?\*\/|\/\/[^\n]*|(?<k1>['"])[^\n]*(?<!\\)\k<k1>))*[\s\n;]*
  // ;/import(?:(?:\b[\s\n\w,\{\}]+\b)from)?[\s\n]*(?<k0>['"])[^\n]*(?<!\\)\k<k0>([ \t]*assert[ \t]*\{[^;\n]+)?/
}

const identifierMap = new Map<string, string>()
let i = 0n
export const toIdentifier = (value: string) => {
  let result = identifierMap.get(value)
  if (!result) {
    identifierMap.set(value, (result = `__complugin_unique_id$${++i}_`))
  }

  return result
}

export const createTempFileName = () => {
  return path.join(
    tmpdir(),
    '_nodejs_complugin',
    createHash('md5')
      .update(process.cwd() + Math.random())
      .digest('base64url') + '.tmp'
  )
}

export const mergeSourceMap = (map1?: SourceMapInput, map2?: SourceMapInput): SourceMap | undefined | null => {
  if (map1 && typeof map1 === 'string') {
    try {
      map1 = JSON.parse(map1)
    } catch {
      map1 = undefined
    }
  }

  if (map2 && typeof map2 === 'string') {
    try {
      map1 = JSON.parse(map2)
    } catch {
      map1 = undefined
    }
  }

  if (!map1 && !map2) return

  return (_mergeSourceMap as any)(
    map1 ? { mappings: '', sources: [], version: 3, ...(map1 as any) } : undefined,
    map2 ? { mappings: '', sources: [], version: 3, ...(map2 as any) } : undefined
  )
}

export const doThen = <T, U>(value: Awaitable<T>, then: (value: T) => Awaitable<U>): Awaitable<U> => {
  if (value instanceof Promise) {
    return value.then(then)
  } else if (isPromiseLike(value)) {
    return Promise.resolve<T>(value).then(then)
  } else {
    return then(value)
  }
}

export const freezeProperties = <Obj extends object>(obj: Obj, keys: (keyof Obj)[]) => {
  Object.defineProperties(
    obj,
    Object.fromEntries(
      keys.map(key => {
        return [
          key,
          {
            configurable: true,
            enumerable: true,
            value: (obj as any)[key]
          } as PropertyDescriptor
        ]
      })
    )
  )

  return obj
}

export const isAst = (value: any): value is AcornNode => {
  return value && typeof value === 'object' && typeof value.type === 'string' && typeof value.body === 'object'
}

export const wrapTransformContext = (context: CompluginContext, isEntry: boolean): CompluginTransformContext => {
  ;(context as any).isEntry = isEntry
  return context as any
}

const splitRE = /\r?\n/

const range: number = 2

const posToNumber = (source: string, pos: number | { line: number; column: number }): number => {
  if (typeof pos === 'number') return pos
  const lines = source.split(splitRE)
  const { line, column } = pos
  let start = 0
  for (let i = 0; i < line - 1; i++) {
    if (lines[i]) {
      start += lines[i].length + 1
    }
  }
  return start + column
}

const posToString = (source: string, pos: number | { line: number; column: number } = 0) => {
  if (typeof pos === 'number') {
    if (!pos) return ''

    const lines = source.slice(0, pos).split(splitRE)
    return `${lines.length},${lines.pop()?.length ?? 0}`
  } else {
    return `${pos.line},${pos.column}`
  }
}

export const generateCodeFrameMessage = (
  message: string,
  fileName: string,
  source?: string,
  start?: number | { line: number; column: number },
  end?: number | { line: number; column: number }
) => {
  if (!source || start != null || end != null) {
    return `${fileName}: ${message}`
  } else {
    return `${fileName}(${posToString(source, start)}) ${message}\n${ansiColors.reset(
      generateCodeFrame(source, start, end)
    )}`
  }
}

export const generateCodeFrame = (
  source: string,
  start: number | { line: number; column: number } = 0,
  end?: number | { line: number; column: number }
): string => {
  start = posToNumber(source, start)
  end = posToNumber(source, end || start)
  const lines = source.split(splitRE)
  let count = 0
  const res: string[] = []
  for (let i = 0; i < lines.length; i++) {
    count += lines[i].length + 1
    if (count >= start) {
      for (let j = i - range; j <= i + range || end > count; j++) {
        if (j < 0 || j >= lines.length) continue
        const line = j + 1
        res.push(`${line}${' '.repeat(Math.max(3 - String(line).length, 0))}|  ${lines[j]}`)
        const lineLength = lines[j].length
        if (j === i) {
          // push underline
          const pad = Math.max(start - (count - lineLength) + 1, 0)
          const length = Math.max(1, end > count ? lineLength - pad : end - start)
          res.push(`   |  ` + ' '.repeat(pad) + '^'.repeat(length))
        } else if (j > i) {
          if (end > count) {
            const length = Math.max(Math.min(end - count, lineLength), 1)
            res.push(`   |  ` + '^'.repeat(length))
          }
          count += lineLength + 1
        }
      }
      break
    }
  }
  return res.join('\n')
}

export const hasBuiltinsId = (moduleIds: Iterable<string>) => {
  const set = new Set(moduleIds)
  for (const id of builtinModules) {
    if (set.has(id)) return true
  }

  return false
}

export const normalizeSourceMap = (map: SourceMap, absOutputFileName: string, sourcesContent = true) => {
  let sources = map.sources
  if (isArray(sources)) {
    map.sources = sources = sources.filter(Boolean)
    const sourceAbsDirname = path.dirname(absOutputFileName)
    sources.forEach((source, index, sources) => {
      source = path.normalize(source)
      if (path.isAbsolute(source)) {
        sources[index] = path.relative(sourceAbsDirname, source)
      }
    })
  }

  if (!sourcesContent) {
    map.sourcesContent = null!
  }

  return map
}

export const addSourceComment = (code: string, comment: string) => {
  return code.endsWith('\n') ? code + comment : code + '\n' + comment
}

type ArrayAble<T> = T | ArrayAble<T>[]

const optimizeRegExpSourcePattern = (source: string) => {
  source = source.replace(/\(\?(:|\<?\=)/g, '(')
  while (true) {
    if (/^(\^)?\((\?:)?/.test(source) && /\)(\$)?$/.test(source)) {
      source = source.replace(/^(\^)?\((\?:)?/, '$1').replace(/\)(\$)?$/, '$1')
    } else {
      let tmp = source
        .replace(/(?:\.\+?(\?)?)(?:\.[\+\*]?\??)+/g, (_, $1 = '') => (_.includes('+') ? '.+' + $1 : _))
        .replace(/(?:\.(\*)?\??)(?:\.([\*\?]))/g, (_, $1, $2) => ($1 === '*' || $2 === '*' ? '.*' : _))
        .replace(/\.([\+\?\*]+)/, (_, $1) => '.' + ($1.includes('+') ? '+' : '*') + ($1.includes('?') ? '?' : ''))
        .replace(
          /^\^?(\.[\*\+]\??|\(\.[\*\+]\??(\.[\*\+]?\??|\(\.[\*\+]?\??\))*\))+(?!\$?$)|(?<!^\^?)(\.[\*\+]\??)+\$?$/g,
          _ => (_.includes('+') ? '.' : '')
        )
        .replace(/^[\+\*\?]+/g, '')

      if (tmp === '$' || tmp === '^' || tmp === '^$' || !tmp) {
        source = '.*'
        break
      }

      if (tmp === source) break

      source = tmp
    }
  }

  return source
}

export const optimizeMatchPatterns = (patterns: ArrayAble<null | undefined | string | RegExp>) => {
  if (!patterns) return
  const exts = new Set<string>()
  const regexpExts = new Set<string>()
  const regexps: RegExp[] = []
  for (let item of castArray(patterns).flat()) {
    let match
    if (typeof item === 'string') {
      item = item.replace(/\*\*+([\\\/]+\*+)*/g, _ => '**')
      if (/^(\*+([\\\/]\**)?)+$/.test(item)) {
        return [/./]
      } else if ((match = item.match(/^\**((?:\.\w+)+)$/))) {
        exts.add(match[1].replace(/^\./, ''))
      } else if ((match = item.match(/^\**(\.\w+)?\.\{([\w\.,]+)\}(\.\w+)?$/))) {
        const [_, e1 = '', p2, e3 = ''] = match
        p2.split(',').forEach(p => {
          if (p) {
            exts.add(`${e1}.${p}${e3}`.replace(/^\./, ''))
          }
        })
      } else if (item) {
        regexps.push(globToRegExp(item))
      }
    } else if (item instanceof RegExp) {
      let source = optimizeRegExpSourcePattern(item.source)
      let flags = item.flags.replace(/[mg]/g, '')
      let match

      switch (source) {
        case '.':
        case '.*':
        case '.+':
          return [/./]
      }

      if (source.startsWith('\\.') && source.endsWith('$')) {
        const extSource = source.slice('\\.'.length, source.length - '$'.length)
        if ((match = extSource.match(/^\(([\w\\\.\?\|]+)\)$/))) {
          match[1].split(/(?<!\\)\|/).forEach(m => (/^\w+$/.test(m) ? exts.add(m) : regexpExts.add(m)))
        } else {
          regexpExts.add(extSource)
        }
      } else {
        regexps.push(new RegExp(source, flags))
      }
    }
  }

  for (const e of exts) {
    regexpExts.add(e.replace(/\./g, '\\.'))
  }

  if (regexpExts.size > 0) {
    regexps.push(new RegExp(`^\\.(${[...regexpExts].join('|')})$`, 'i'))
  }

  switch (regexps.length) {
    case 0:
      return
    default:
      return regexps
  }
}
